Skip to content

feat(fuji): UI polish, workspace actions, state architecture, and date fields#1647

Merged
braden-w merged 64 commits intomainfrom
opencode/misty-rocket
Apr 11, 2026
Merged

feat(fuji): UI polish, workspace actions, state architecture, and date fields#1647
braden-w merged 64 commits intomainfrom
opencode/misty-rocket

Conversation

@braden-w
Copy link
Copy Markdown
Member

Fuji had the bones of a journal app but no personality. The sidebar was static, search did nothing visible, entries had no timestamps the user could control, and the whole state layer was a single 300-line component. This reworks the app from data layer through UI.

The workspace definition now owns entry lifecycle through typed actions. Creating and updating entries goes through withActions() instead of ad-hoc table.set() calls scattered across components:

const workspace = createFujiWorkspace()
  .withActions(({ tables, kv }) => ({
    entries: {
      create: defineMutation({
        input: Type.Object({ title: Type.String(), type: Type.String() }),
        handler: ({ title, type }) => {
          const id = generateId();
          tables.entries.set({ id, title, type, ... });
          return { id };
        },
      }),
      update: defineMutation({
        input: Type.Object({ id: Type.String(), ... }),
        handler: ({ id, ...fields }) => {
          tables.entries.update(id, { ...fields, updatedAt: DateTimeString.now() });
        },
      }),
    },
  }));

State went from one monolith to three focused modules.

The old +page.svelte mixed view preferences, entry CRUD, search filtering, and sort logic in one file. Now each concern has its own module that imports the workspace directly:

+page.svelte (orchestration only)
  entries.svelte.ts  (CRUD, active entries, search filtering)
  view.svelte.ts     (sort preference, type/tag filters, view mode)
  workspace.ts       (definition, actions, factory)

Components import state directly instead of receiving it through props. EntriesTable, EntryTimeline, and EntriesSidebar all read from the state modules, which eliminated ~15 prop-drilling chains across the page.


The UI got a proper shell. An AppHeader with a command palette (Cmd+K), a resizable sidebar that shows search results inline when a query is active, a SyncStatusIndicator popover for sign-in and connection status, and rich empty states for each view mode.

Entries gained a user-controllable date field backed by the NLP date input from @epicenter/ui. System timestamps (createdAt, updatedAt) moved to a metadata bar inside the editor. Sort preferences persist via workspace KV.


The UI package picks up a few pieces too. The NLP date input got renamed from NLPDateInput to NaturalLanguageDateInput, its parsing pipeline was trimmed to just the datetime-string conversion it actually needs, and the timezone combobox was extracted to its own packages/ui/src/timezone-combobox/ module. A GitHubButton component replaces the hand-rolled SVG icon that was duplicated across apps.

Also flattened the fuji file structure: workspace/definition.ts + workspace/workspace.ts collapsed into a single workspace.ts, state/entries-state.svelte.ts became entries.svelte.ts, barrel files deleted, auth.ts inlined into client.ts.

30 files changed, +2154/-619. Stacks on #1644, #1645, #1646 (all merged).

braden-w added 30 commits April 8, 2026 21:34
Entries now support pinning (sort to top of lists) and soft deletion
via deletedAt timestamp. Soft-deleted entries are filtered from the
active entries list and sidebar. This is critical for CRDT conflict
safety when two devices diverge—edits on a deleted entry resolve
cleanly instead of causing data loss.
Move entry CRUD, view mode, filters, and selection state out of
+page.svelte into entries-state.svelte.ts and view-state.svelte.ts.
Follows the factory function singleton pattern used by Opensidian and
Honeycrisp. The page component is now purely rendering logic.
Add Cmd+K command palette using @epicenter/ui/command-palette. Entries
are searchable by title, subtitle, tags, and type via the palette's
built-in fuzzy filtering. Also enhance the table's global filter to
search across all metadata fields instead of just title.
When the user types in the sidebar search input, type/tag/recent groups
are replaced with a flat list of matching entries. Matches against
title, subtitle, tags, and type (case-insensitive). Clearing the search
restores the normal browsing view.
Replace the timestamps footer with a StatusBar component showing word
count, tags, and creation/update dates. Add strikethrough (Cmd+Shift+S)
and underline (Cmd+U) marks to the ProseMirror schema. Word count
updates in real-time via a ProseMirror plugin.
Add sortBy KV key to the workspace definition with options for
dateEdited, dateCreated, and title. Expose via viewState with getter
and setter. Persisted across reloads and synced across devices.
Remove unused parseDateTime/format import from EntryEditor (orphaned
after StatusBar extraction), unused SearchIcon import from FujiSidebar,
and fix JSDoc that said Tiptap when the code uses ProseMirror directly.
The vault project needs to import the fuji workspace definition to wire
up filesystem persistence and markdown materialization. Adds the factory
function and re-exports the definition from the workspace barrel.
Add a create action to the workspace factory so entry creation is
available to CLI, AI tools, and any future consumer—not just the
Svelte UI. client.ts now uses createFujiWorkspace() instead of
calling createWorkspace() directly. entries-state delegates to the
workspace action and only adds the view-state selection side-effect.
Add a create action to createFujiWorkspace().withActions() so entry
creation is available to CLI, AI tools, and any future consumer.
client.ts now uses the factory instead of calling createWorkspace()
directly, removing the unused createWorkspace import.
The persisted sortBy preference now drives the initial sort column in
EntriesTable and the date grouping field in EntryTimeline. Clicking a
column header in the table propagates the change back to the KV store
so it survives reloads and syncs across devices.
Replace three inline filter-clearing calls with a single onClearFilters
callback that delegates to viewState.clearFilters(). Removes the last
dead method in the view state module.
Vite's strict module resolution requires typebox to be listed
explicitly. Other apps (Opensidian, tab-manager) already list it
via catalog.
Remove duplicate `import type * as Y from 'yjs'` in EntryEditor.
Change StatusBar entry prop from `Entry | null` to `Entry` since
it's only rendered inside a selectedEntry guard. Remove stale
'workspace actions' framing from entries-state JSDoc.
… rich empty states

Replaces SidebarProvider with Resizable.PaneGroup for drag-resizable
sidebar. Adds persistent AppHeader with branding, GitHub link, theme
toggle, and keyboard shortcut hints. Adds GlobalStatusBar with entry
count. Upgrades empty states in table/timeline to use Empty.* compound
component from packages/ui.

Spec: specs/20260408T160000-fuji-ui-polish.md
chrono-node/en parses NL text ("next tuesday 3pm", "tomorrow at noon")
into date components. Intl.DateTimeFormat resolves those components in
the user-selected IANA timezone to a UTC instant, avoiding DST edge
cases. The result serializes as DateTimeString for two-way Svelte 5
binding via $bindable().

Components: NaturalLanguageDateInput (main), TimezoneCombobox
(searchable IANA selector), parse-date.ts (parsing + bridging utility).
6 bun:test cases passing.
…ompat

Sidebar.Root with default collapsible uses position:fixed which fights
Resizable panels. Setting collapsible=none renders a plain div that
flows correctly inside Resizable.Pane. Also swaps Lucide GitHub icon
for Simple Icons SVG (matching Opensidian).
… packages/ui

Schema defines _v as Arktype '1' which parses as number literal 1.
Action was writing string '1', causing validation failure and entries
never appearing in the reactive SvelteMap.

Also moves GithubIcon SVG from per-app local copies to
@epicenter/ui/github-icon for shared use.
- Remove sidebarCollapsed KV key + getter/setter (sidebar is resizable
  now, not collapsible)
- Remove allEntries, deletedEntries getters (no consumers)
- Remove softDeleteEntry, restoreEntry, permanentlyDeleteEntry,
  togglePin methods (no callers, no UI for these features yet)
- Remove dead dateTimeStringNow import
- Remove redundant Tooltip.Provider from AppHeader (layout already
  provides one)
…HubButton

Installs github-button from shadcn-svelte-extras into packages/ui.
Replaces the hand-rolled GithubIcon SVG + tooltip-wrapped Button in
Fuji and the buttonVariants anchor in Opensidian with the standardized
GitHubButton component. Removes the now-unused github-icon package.
…E2E encryption

Add deriveKeyFromPassword, generateSalt, and buildEncryptionKeys to the
crypto module. These bridge password input to the existing encryption
flow so vault workspaces can derive keys client-side via PBKDF2.

Includes 13 tests covering determinism, round-trips, and end-to-end
integration through encrypt/decrypt.
StatusBar "Created" date is now clickable—opens a Popover with a
chrono-node suggestions input and IANA timezone combobox. Selecting a
suggestion converts the Date + timezone to DateTimeString and writes
it back via onUpdate({ createdAt }).

Removed yeezy-dates in favor of direct chrono-node. Merged the
duplicate nlp-date-input/ folder into natural-language-date-input/
so everything lives under one package export path.

Also fixed chrono-node/en → chrono-node import (Vite resolves from
the consuming app's context, not the declaring package's node_modules).
The spec's Input+Preview+Confirm component was superseded by the
suggestions-based NLPDateInput. Nobody imported the original component,
and its 200-line DST-safe chrono→Intl bridging function was only called
by that dead component.

Deleted: natural-language-date-input.svelte, parseNaturalLanguageDate(),
extractDateComponents(), getOffsetMillisecondsForTimezone(), isValidDate(),
DateComponents type, ParseNaturalLanguageDateResult type, and their tests.

Kept: toDateTimeString() and localTimezone() (both alive in StatusBar),
nlp-date-input.svelte, timezone-combobox.svelte.
…elpers

Unify the pipe-delimited DateTimeString parser (duplicated in 4 files)
and entry search predicate (duplicated in sidebar + table) into
$lib/utils/ so search semantics stay consistent across views.
…pers

Both were trivial passthroughs to workspace table methods with no
side effects. +page.svelte now uses entriesMap.get() for reactive
lookup and workspace.tables.entries.update() directly.

Skipped: pinned field removal (requires CRDT schema migration),
BadgeList inlining (TanStack Table renderComponent forces component refs).
Remove workspace/index.ts and state/index.ts barrel re-exports—all
consumers now import directly from definition.ts, entries-state.svelte.ts,
and view-state.svelte.ts.

Inline GlobalStatusBar (16 lines) into +page.svelte since it had no
reuse and was app-specific status markup.
entries-state no longer imports viewState. The create-then-select
pairing now lives in the page orchestrator where the coupling is
explicit and visible.
…Sidebar

Drop 8 callback/state props that were just viewState pass-throughs.
The sidebar now imports viewState directly and only accepts entries
as a prop. Renamed FujiSidebar → EntriesSidebar since the Fuji
prefix was redundant inside apps/fuji.
…agFilter

The old names implied filtering logic; they just set state variables.
New names match the setSearchQuery convention already in viewState.
…ombobox

- Fix StatusBar importing NLPDateInput from deleted @epicenter/ui/nlp-date-input
  path; consolidated to @epicenter/ui/natural-language-date-input
- Rename parse-date.ts → datetime-string.ts (file no longer parses dates,
  it serializes DateTimeString and gets local timezone)
- Move timezone-combobox.svelte to its own packages/ui/src/timezone-combobox/
  folder—it's a standalone reusable component, not NLP-specific
braden-w added 28 commits April 10, 2026 23:45
… palette

CommandPalette from @epicenter/ui already registers its own cmd+k
keydown handler. Fuji's additional handler on svelte:window toggled
the same bound state twice per keypress, causing the dialog to flash
open then immediately close. Matches opensidian's working pattern of
letting CommandPalette own the shortcut.
Replaces the standalone dateTimeStringNow() function with methods on
the DateTimeString validator itself: .now(), .parse(), .stringify(),
.toDate(), and .is(). Follows the JSON.parse/JSON.stringify pattern
for familiarity. Also tightens validation from indexOf('|') to a
strict regex, and brands DateIsoString/TimezoneId.
parseDateTime was a 3-line wrapper doing new Date(dts.split('|')[0]).
DateTimeString.toDate now provides the same operation as a first-class
workspace utility. Four callers updated, file removed.
Consumer-side migration for the DateTimeString companion object.
Updates fuji, honeycrisp, and CLI test imports.
Delete 5 reference files (4 were 100% duplicates of SKILL.md, 1 had 2
unique sections). Merge view-mode branching and data-driven markup from
component-patterns.md. Add template gotchas section documenting the
unicode escape pitfall in HTML context. Rename "Mutation Pattern
Preference" to "Mutation Patterns".
Both ./workspace and ./materializer exports pointed to non-existent
index.ts files with zero consumers across the monorepo. chrono-node
was listed but never imported—the natural language date input lives
in @epicenter/ui. Also sorts deps alphabetically.
…o $derived

activeEntries was recently changed from a $derived value to a plain
function, causing it to re-filter on every call (4× per render cycle).
Wrap in createEntriesState() factory matching the view.svelte.ts
pattern, keep entriesMap inside the closure, and expose entries.active
as a getter over a single $derived computation.
…status bar

Entries now have a required `date` field (DateTimeString) representing the
user's intended date for the content—publish date, event date, reference date.
Defaults to creation time, editable via NaturalLanguageDateInput popover in
the metadata section.

createdAt and updatedAt are no longer user-editable. Both display as read-only
timestamps in the status bar alongside word count. Removed duplicate tag
display from status bar.
Remove migrationDialog.openDialog() (zero callers) and
terminalState.printWelcome() (zero callers — ensureWelcome is called
internally by toggle/show/init). Fix stray leading tab on
terminal-state import.
…terializer

StatusBar was 21 lines with no state—inlined into EntryEditor. Date picker
now shares a single flex row with Type and Tags instead of sitting orphaned
on its own line. Removed unnecessary DateTimeStringType alias.

Fixed materializer frontmatter missing the new date field.
EncryptionKeys is defined as [T, ...T[]] via arktype, but createCliUnlock
declared it as T[]. Function parameter contravariance made the context's
signature incompatible with the inline type.
…ist views

sortBy values were 'dateEdited'/'dateCreated' which required mapping tables
to translate to the actual column IDs 'updatedAt'/'createdAt'. Replaced with
direct column names: 'date' | 'updatedAt' | 'createdAt' | 'title'.

Added date column to EntriesTable. Updated EntryTimeline to support grouping
by date. Default sort is now 'date'—the most natural ordering for a CMS.
…sort

Replaced dateEdited/dateCreated with updatedAt/createdAt in the sortBy KV
union—eliminates the mapping tables in EntriesTable that existed solely to
translate between two naming conventions. Added date column to the table
and date as the default sort (most natural for a CMS). Timeline now
supports grouping by the user-defined date field.
Merge definition.ts and workspace.ts into lib/workspace.ts. The two-file
directory added indirection for one table and one action. Update all
imports from $lib/workspace/definition and ./workspace/workspace.
Eight lines, one consumer. The session persisted state is only used by
createAuth in client.ts—no reason for a separate file.
Move matchesEntrySearch into entries.svelte.ts—it's entry-specific logic
with two consumers, not a search module. Rename the singleton export to
entriesState to match the monorepo convention (viewState, skillsState,
notesState).
Both components now import viewState and workspace directly instead of
receiving searchQuery, sortBy, selectedEntryId, onSelectEntry, onAddEntry,
and onSortChange as props. Matches EntriesSidebar which already used
direct imports. The only remaining prop is entries (the filtered list).
Replicates the opensidian SyncStatusIndicator pattern—cloud icon in the
top-right header that opens a popover with AuthForm (signed out) or user
info, sync status, and sign-out (signed in).
Previously, editing entry metadata (title, subtitle, tags, type, date)
bypassed updatedAt because only the withDocument onUpdate hook touched
it. Route all field edits through a workspace action so the timestamp
stays consistent across UI, CLI, and AI callers.
The Wave 4 write didn't persist—entries.svelte.ts was still exporting
`entries` instead of `entriesState`, and matchesEntrySearch was missing
after search.ts was deleted.
Type.String() infers as plain string, causing a type mismatch when
the handler passes the value to tables.entries.update which expects
DateTimeString. Type.Unsafe preserves the branded type at the
TypeScript level while emitting identical JSON Schema.
Move createEntry into entriesState so the three identical copies in
+page.svelte, EntriesTable, and EntryTimeline collapse into one method.
Replace the exposed SvelteMap with a get(id) method—the only operation
any consumer actually uses.
AppHeader now imports entriesState directly and calls createEntry itself,
matching the pattern used by all other components. The only remaining
callback prop is onOpenSearch, which controls local page state.
EntryEditor now imports viewState and workspace directly. Back navigation
calls viewState.selectEntry(null), field updates go through the
workspace entries.update action. The only remaining props are entry and
yxmlfragment—both computed values from the page's document handle effect.
# Conflicts:
#	apps/fuji/src/lib/components/EntriesTable.svelte
#	apps/fuji/src/lib/components/EntryEditor.svelte
#	apps/fuji/src/routes/+page.svelte
#	apps/honeycrisp/src/lib/state/notes.svelte.ts
#	apps/opensidian/src/lib/components/editor/StatusBar.svelte
#	apps/opensidian/src/lib/components/editor/TabBar.svelte
#	apps/opensidian/src/lib/state/terminal-state.svelte.ts
#	bun.lock
#	packages/cli/test/e2e-honeycrisp.test.ts
#	packages/ui/package.json
#	packages/ui/src/natural-language-date-input/index.ts
#	packages/ui/src/natural-language-date-input/natural-language-date-input.svelte
#	packages/workspace/src/shared/crypto/index.ts
@braden-w braden-w merged commit b54e810 into main Apr 11, 2026
1 of 9 checks passed
@braden-w braden-w deleted the opencode/misty-rocket branch April 11, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant